fix(client)!: bind the auth.* family to the wire shapes better-auth sends - #16537
Conversation
…ends
Thirteen methods of the auth.* namespace ended `return res.json()` with no
return annotation, so lib.dom's `Response.json(): Promise<any>` was their
published type. Each now declares the shape its route actually serves, and
its exported-any-returns.json entry is deleted in the same change:
auth.updateUser -> AuthStatusReceipt
auth.changePassword -> AuthPasswordChangeResult
auth.setInitialPassword -> AuthSetInitialPasswordResult
auth.changeEmail -> AuthStatusReceipt
auth.sendVerificationEmail -> AuthStatusReceipt
auth.verifyEmail -> AuthEmailVerificationResult
auth.sessions.revoke/Others/All -> AuthStatusReceipt
auth.twoFactor.verifyTotp -> AuthTwoFactorVerificationResult
auth.twoFactor.disable -> AuthStatusReceipt
auth.twoFactor.verifyBackupCode -> AuthTwoFactorVerificationResult
auth.accounts.unlink -> AuthStatusReceipt
The shapes were read off the wire against a real server, not off
better-auth's own .d.ts: the real AuthPlugin mounts over a real Hono app with
a real AuthManager (better-auth 1.7.2), once on the in-memory engine and once
over a real SqlDriver driven through the real ObjectStackClient. Twice the
vendor's declaration was the wrong answer: updateUser's stub promises the
updated user but the handler answers `{ status: true }`; verifyEmail's stub
declares `user` required but the handler answers `null` on a plain
verification.
Timestamps are ISO-8601 strings, never Date and never revived (maintainer
ruling on the family card): the adapter runs `supportsDates: false` and
JSON.stringify puts the ISO string back on the wire.
auth.deleteUser is deliberately NOT bound and keeps its ledger entry: its
route is switched off by maintainer ruling and answers HTTP 404 with a
zero-byte body, so `this.fetch` throws before `res.json()` runs and no
declared return type can be honest.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YFY46JydE1gMxQG1TqBcMZ
📓 Docs Drift CheckThis PR changes 1 package(s): 4 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 1 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 14 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 731fe7b75d6cac75aadf1b185d0dd4da31e8a20d && git checkout 731fe7b75d6cac75aadf1b185d0dd4da31e8a20d
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 46e626513940806af0e8da5dbe1eeec12eae1c45 f293df6394176a54508eb0773a4ace1d43834709 && git checkout -B drift-repro 46e626513940806af0e8da5dbe1eeec12eae1c45 && git merge --no-ff f293df6394176a54508eb0773a4ace1d43834709
node scripts/docs-audit/affected-docs.mjs --json 46e626513940806af0e8da5dbe1eeec12eae1c45
|
Part of #14313 — card 2 of 3 of the #12104 family. Merging this does not complete that card: thirteen of its fourteen methods are bound here, and the fourteenth (
auth.deleteUser) is an open question stated below, so the card stays open for the PM seat to settle deliberately.Clause-② is yes by the maintainer's #12104 ruling (this narrows published return types), so the PR is draft and carries
needs:contract-reviewon both carriers. It waits for an at-tier contract reviewer; auto-merge is not armed and it is not enqueued.What changed
Thirteen
auth.*methods endedreturn res.json()with no return annotation, solib.dom'sResponse.json(): Promise< any >was their published type. Each now declares the shape its route serves, and itsexported-any-returns.jsonentry is deleted in the same change.auth.updateUseranyAuthStatusReceiptauth.changePasswordanyAuthPasswordChangeResultauth.setInitialPasswordanyAuthSetInitialPasswordResultauth.changeEmailanyAuthStatusReceiptauth.sendVerificationEmailanyAuthStatusReceiptauth.verifyEmailanyAuthEmailVerificationResultauth.sessions.revoke/revokeOthers/revokeAllanyAuthStatusReceiptauth.twoFactor.verifyTotpanyAuthTwoFactorVerificationResultauth.twoFactor.disableanyAuthStatusReceiptauth.twoFactor.verifyBackupCodeanyAuthTwoFactorVerificationResultauth.accounts.unlinkanyAuthStatusReceiptLedger: 35 entries before, 22 after — exactly these thirteen deleted,
ObjectStackClient.auth.deleteUserdeliberately kept, nothing else touched. Population re-derived from the ledger file itself at the merge base (a5eccf925):auth.*= 14, decomposing 7 direct / 3 sessions / 3 twoFactor / 1 accounts.unlink — it matched the card's 7/3/3/1. New exports:AuthWireUser,AuthStatusReceipt,AuthPasswordChangeResult,AuthEmailVerificationResult,AuthTwoFactorVerificationResult,AuthSetInitialPasswordResult. No method body changed; the only prose edits are JSDoc on the bound members.The shapes were read off the WIRE — three arrangements, not the vendor's
.d.tsAuthPluginroute mounts (registerAuthRoutes, including ObjectStack's ownset-initial-passwordandsend-verification-emailwrappers and thedelete-usercatch-all path) over a real Hono app with a realAuthManager(better-auth 1.7.2) on the in-memory engine, cookie-driven: all 14 routes plus their refusal variants, 56 exchanges recorded with status, content-type, byte count and raw body.SqlDriver(better-sqlite3) with the plugin's ownauthIdentityObjectsschema, driven through the realObjectStackClientwith only the socket stood in for (fetch: (u, init) => app.request(u, init), bearer auth): every member resolved or rejected exactly as the annotation now says.phoneNumber,adminandtwoFactorplugins on, to see which plugin members reach the wire user.The receipts (8 routes), measured identically on all of them:
The payload routes:
The wire user on the real SQL driver (admin + twoFactor + phoneNumber on):
Where the vendor's own declarations were the wrong answer
updateUser's OpenAPI stub promises{ user }(and the SDK's JSDoc said "Returns the updated user"); the handler answers{ status: true }and puts the new fields into the session cookie. The receipt is what is declared, and the JSDoc is corrected.verifyEmail's stub declaresuserrequired; the handler answersuser: nullon a plain verification and the updated user only on a change-email verification — declaredAuthWireUser | null.nullon the SQL drivers ("image":null,"banReason":null) and as an ABSENT key on the in-memory engine (which does not materialise unset columns) — both measured, so each is?: … | null. The plugin-conditional members (twoFactorEnabled;role/banned/banReason/banExpires;phoneNumber/phoneNumberVerified) were measured with and without their plugin and are optional. No index signature.Secrets in the bound shapes (as the card asks)
AuthPasswordChangeResult.tokenandAuthTwoFactorVerificationResult.tokenare unsigned session tokens (bearer credentials). Both were already on the wire; the types name them and their JSDoc marks them SECRET. Nothing is widened.AuthWireUsercarries no secret: no password hash, no backup codes (backupCodesstay only ontwoFactor.enable/generateBackupCodes, already typed before this card, untouched here).AuthStatusReceipt,AuthEmailVerificationResult,AuthSetInitialPasswordResultcarry nothing sensitive.Timestamps: ISO-8601
string, neverDate— the ruling HAS sites hereAuthWireUser.createdAt/updatedAt(andbanExpires) are the vendor'sDate-typed fields. The adapter is declaredsupportsDates: false, better-auth revives the stored string into aDateserver-side, andJSON.stringifyputs an ISO-8601 string back on the wire (measured above). They are declaredstring, JSDoc says ISO-8601, a type-level pin holds them there (toEqualTypeOf< string >), and the reverse pin refuses.getTime()on them. No revival layer exists in the SDK.auth.deleteUseris NOT bound — an open question for the reviewerIts route is switched OFF by maintainer ruling (2026-08-12 on #7735;
auth-route-ledger.tsbooks itdisabled). Measured against a real server, through the real client:this.fetchthrows on every non-2xx beforeres.json()runs, so the method has no success path a caller can observe. No declared return type can be honest for a value the runtime never delivers, and binding the vendor's success shape ({ success: true, message: 'User deleted' | 'Verification email sent' }) today would declare a capability the runtime does not have — the first-commandment shape the ruling names. Its ledger entry stays open, the JSDoc says why, and the pin file holds it as an EQUALITY (toEqualTypeOf< any >) so the line that must change is named. The three readings and what each costs are in the dev report on #14313; I did not pick one.Verification
Final commit
f293df639, clean tree (git status --porcelainempty).pnpm --filter @objectstack/client check:exported-any-returns→✅ … 35 ledgered site(s) still open, with the positive control read off the builtdist/index.d.ts:oauth.applications.getresolves toPromise< OAuthApplication >andautomation.triggertoPromise< AutomationResult >, both absent from the ledger, whileauth.sessions.revokeOthersstill readPromise< any >. At HEAD:✅ no NEW exported callable of @objectstack/client resolves to any: 317 callables reached (52 caller-supplied generics, not counted as erasure), 22 ledgered site(s) still open.Its--self-testreportsLedger is exact in both directions.pnpm --filter @objectstack/client typecheck—tsc --noEmitclean, thencheck:test-typecheck: OK — … 0 file(s) / 0 error(s).pnpm --filter @objectstack/client test— 34 files, 444 tests, all passing.setInitialPassword's annotation gives two independent reds. The mutation was proved on disk (removed-text count 1→0, injected marker 0→1, source blob moved), rebuilt, and proved to have reacheddist/withablation-dist-preflight.mjs --absent(marker absent from all 6 built files). Then:check:exported-any-returnsexit 1 —❌ 1 exported callable(s) … not ledgered: ObjectStackClient.auth.setInitialPassword resolves to Promise< any >;check:test-typecheckexit 1 —3 type error(s)inreturn-type-precision.test.ts(two equality pins and the now-unused@ts-expect-error, TS2578). Restored withgit checkout HEAD --naming the absolute path under a trap, proved bygit diff HEADempty,git status --porcelainempty and the blob hash equal to HEAD's (96ecd278…), rebuilt, marker proved present again, gate green at 22.node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands(no paths; the change set from the merge base): Reconciliation total 56;--ran→✓ 56 derived famil(ies) accounted for — 56 run, 0 NOT-MEASURED. 53 exit 0. The other three are prerequisite refusals that need a full-workspace build, not measured and reported as such:check:skill-examples(wantspackages/client-react/dist),check:dual-build-cjs-loads(exit 3, "This is NOT a pass: nothing was measured"),check:type-check-debt(exit 3, same class). Declared narrowing: a full-workspace build is lock-governed on a shared box that spent the shift building another lane's closure; CI builds the farm and runs all three regardless, and none of them reads a file this diff touches beyond thepackages/clientclosure already built and typechecked here.check-changeset-fixed(70 packages in sync),check:authz-resolver,check:error-code-casing(5750 files, no unlisted code),check:filter-alias-parity.check:adr-0087-registrationexit 0 — thetype-surface-onlydisposition verified for all thirteen dotted member references (unannotated → Promise< X >each);check-changeset-no-majorexit 0 (minor);check:nul-bytesexit 0;check-partof-closing-keywordrun against this body and the branch's commits (no commit carries a card trailer).eslint --no-inline-config --format jsonover the two changed.tsfiles — 2 files linted, 0 errors, 0 warnings. Population evidence:eslint.config.mjsscopespackages/**/*.{ts,tsx,mts,cts}blocks and states it uses noparserOptions.project(not type-aware), so this diff cannot move the verdict of any untouched file; the repo-widepnpm lintis CI's.git grepfinds zero call sites of the thirteen bound methods outsidepackages/client(the other hits are CHANGELOG/docs prose).../objectuiis not checked out in this container; it consumes@objectstack/clientfrom npm at a released version.Acceptance notes · 验收备注
ObjectStackClientis silently signed out byauth.twoFactor.disable(), the enrolment-lanetwoFactor.verifyTotp()andchangePassword({ revokeOtherSessions: true })— the server rotates the session and the SDK stores neither the echoed token norset-auth-token#16534: a bearer-modeObjectStackClientis silently signed out bytwoFactor.disable(), enrolment-laneverifyTotp()andchangePassword({ revokeOtherSessions: true })— the server rotates the session and the SDK stores neither the echoed token norset-auth-token(measured through the real client: the very next call answers 401).POST /two-factor/verify-totpon the enrolment lane echoesuser.twoFactorEnabled: falseafter the flag has flipped — the vendor's pre-rotation snapshot, whichtwo-factor-rotated-token-echorepairs fortokenonly #16535: enrolment-laneverify-totpechoesuser.twoFactorEnabled: falseafter the flag flipped — the vendor's pre-rotation snapshot;two-factor-rotated-token-echo.tsrepairstokenonly.verifyEmail({ callbackURL })answers a 302 with an empty body; the SDK'sfetchfollows it andres.json()parses whatever the callback target serves — documented in the JSDoc; not reproduced through the real client (the probe transport does not follow redirects).delete-usernote says the route "answers 404"; for the holder of the last local credential the first refusal is plugin-auth's 409LAST_LOCAL_CREDENTIALguard — the note describes the vendor's answer past the guard and is not wrong, only earlier-terminated.Scope
Only the fourteen methods this card names, on
packages/client/src/index.ts, plus the ledger, the pin file and the changeset. Theorganizations.*card (#14314) is serialized behind this one on the same hot file and is untouched. The #13080 BREAKING-token gate is not addressed here — the ruling says that card is independent.🤖 Generated with Claude Code
Generated by Claude Code